fix: don't mutate shared yaml.Node when rendering schemas concurrently#594
Merged
daveshanley merged 2 commits intoJun 24, 2026
Merged
Conversation
06a5153 to
def111f
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #594 +/- ##
=======================================
Coverage 99.75% 99.75%
=======================================
Files 280 280
Lines 34157 34172 +15
=======================================
+ Hits 34073 34088 +15
Misses 51 51
Partials 33 33
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Adds a unit test exercising the passthrough, nil, alias and content branches of the new node-copy helpers, bringing patch coverage to 100%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes daveshanley/vacuum#912
Summary
NodeBuilder.AddYAMLNodecalls(*yaml.Node).Encode(value)wherevalueis frequently a*yaml.Nodeowned by the model.Encoderuns the value through the desolve stage, which rewritesTag/Stylein place, and the representer returns input*yaml.Nodes as-is (nodevreturns the node unchanged). So rendering a schema mutates the shared node it was built from.When schemas are rendered concurrently — e.g. a linter running rules in parallel — that mutation races with any goroutine reading the same node. A common symptom: a string scalar's tag is briefly elided to
""mid-render, so a concurrent reader checkingnode.Tag == "!!str"sees the wrong type and reports a bogusconst value type does not match schema typeerror.This fixes it by encoding a deep copy whenever the value is a
*yaml.Node, so the desolve mutation lands on a throwaway copy and the model's nodes stay immutable.When this regressed
The shared-node read path is not new — the trigger is a change in
go.yaml.in/yaml/v4's(*Node).Encode, pulled in when libopenapi went from v0.37.x to v0.38.0.go.yaml.in/yaml/v4rc.4 (used up to libopenapi v0.37.2):Encodemarshalled the value to YAML bytes and re-parsed them into fresh nodes. It never touched the caller's node. There is nodesolver.goin rc.4.go.yaml.in/yaml/v4rc.5 (pulled in by libopenapi v0.38.0):Encodewas rewritten into aRepresent→Desolve→Serializepipeline. The newDesolvestage elides inferable tags (n.Tag = "") and normalizes quote style on the represented graph, andRepresent/nodevreturns input*Nodevalues as-is (aliased). SoEncodenow mutates caller-owned nodes.Because libopenapi hands shared model nodes to
Encodeduring inline schema rendering, that new in-place mutation began landing on shared nodes and racing with concurrent readers. This is why the downstream symptom (vacuum #912) appears exactly at vacuum v0.29.3 / libopenapi v0.38.0 and not before.Downstream confirmation in vacuum (which renders schemas concurrently across rules):
GOMAXPROCS=1makes the false error disappear, vacuum v0.29.2 (libopenapi v0.37.2 / yaml rc.4) never reproduces it, and v0.29.3+ does.Changes
datamodel/high/node_builder.go: addencodeSafeValue(deep-copies*yaml.Nodevalues) anddeepCopyYAMLNode; route bothrawNode.Encode(value)call sites throughencodeSafeValue.datamodel/high/base/schema_proxy_test.go: addTestSchemaProxy_ConcurrentRender_DoesNotMutateSharedNodes, which renders a schema concurrently while reading a capturedconstscalar's tag and asserts the tag is never mutated.datamodel/high/node_builder_test.go: addTestEncodeSafeValue_DeepCopiesYAMLNodes, a unit test covering the passthrough, nil, alias and content branches of the new helpers.Reproduction
The race detector flags it on
main:Verification
go test -racego test ./datamodel/high/...go test ./renderer/... ./bundler/...Notes
deepCopyYAMLNodeadds one allocation per encoded*yaml.Node; negligible next to the represent/desolve/serialize/recompose round-tripEncodealready performs, and only on the*yaml.Nodepath.(*yaml.Node).Encodeingo.yaml.in/yaml/v4should not mutate caller-owned nodes at all — rc.4 did not. That would fix every caller and restore the previous contract. This PR keeps the change inside libopenapi so it doesn't depend on an upstream yaml release.🤖 Generated with Claude Code